Derive total_byte_size for join statistics - #24536
Conversation
A join knew how many rows it would emit but not how wide they were: every join type but semi and anti reported no total byte size at all. An operator above it reading that size finds nothing, so `hash_join_single_partition_threshold`, a byte threshold, falls back to counting rows. The width of an output row follows from the sides the join emits, which the join type already says: both sides for an inner or outer join, the preserved side for a semi or anti join, and one side plus a boolean for a mark join. Multiplying it by the estimated cardinality gives the size. Semi and anti joins keep the size they derive from their column statistics, which knows which columns survive; the width fills a gap rather than replacing a better answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012YiAABcW4WSqij31zz2P6c
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #24536 +/- ##
==========================================
+ Coverage 80.87% 81.32% +0.44%
==========================================
Files 1101 1117 +16
Lines 375765 396322 +20557
Branches 375765 396322 +20557
==========================================
+ Hits 303915 322299 +18384
- Misses 53747 55191 +1444
- Partials 18103 18832 +729 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
run benchmarks |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
run benchmarks |
|
run benchmark tpc-ds |
|
run benchmark tpcds |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
run benchmark tpcds |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
asolimando
left a comment
There was a problem hiding this comment.
LGTM, a few non-blocking comments. As suggested elsewhere, I'd be curious to see if #23975 can measure any improvement for this
| // Only fill a gap: a size the join derived from its column statistics | ||
| // knows which columns it keeps, which an average row width cannot. | ||
| (Precision::Absent, Some(width)) => { | ||
| Precision::Inexact((stats.num_rows as f64 * width) as usize) |
There was a problem hiding this comment.
Minor: maybe we could round here instead of truncating?
|
run benchmark tpch tpcds tpch10 |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
run benchmark tpcds tpch10 |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
|
Benchmark for this request failed before finishing (Kubernetes reason: Benchmarks requested: Kubernetes messageFile an issue against this benchmark runner |
viirya
left a comment
There was a problem hiding this comment.
Thanks for splitting this out — deriving a size instead of reporting Absent is clearly worth having, and the benchmark table showing three TPC-DS plans improving with no regressions is convincing. A few questions inline.
| ) -> Result<Statistics> { | ||
| // Width of one output row, from the sides this join emits. Without it a join reports | ||
| // no size and `hash_join_single_partition_threshold` falls back to counting rows. | ||
| let width = |stats: &Statistics| match ( |
There was a problem hiding this comment.
Why an f64 average row width rather than staying in Precision? stats_cartesian_product in cross_join.rs derives its output size with Precision arithmetic throughout (left.total_byte_size.multiply(&right_row_count).add(...)), which keeps integer precision and gets the Exact/Inexact lattice for free. Here the width round-trips through f64. Was Precision arithmetic considered and rejected — presumably because the per-side row-count multiplication that works for a cartesian product doesn't map onto an equi-join? If f64 is the right call, a brief comment saying why would help, since the neighbouring code sets a different precedent.
Also: reading through get_value() means the result is Inexact even when both sides are Exact. I assume that's deliberate (an average row width is inexact regardless), but worth confirming it's intent rather than an artifact.
| _ => None, | ||
| }; | ||
| // The boolean a mark join appends is one bit per row. | ||
| const MARK_COLUMN_WIDTH: f64 = 1.0 / 8.0; |
There was a problem hiding this comment.
1/8 byte matches Field::new("mark", DataType::Boolean, false) — non-nullable, so there's no validity buffer to account for. Noting I checked, in case that field ever becomes nullable and this constant needs revisiting.
| // The boolean a mark join appends is one bit per row. | ||
| const MARK_COLUMN_WIDTH: f64 = 1.0 / 8.0; | ||
| let output_width = match join_type { | ||
| JoinType::LeftSemi | JoinType::LeftAnti => width(&left_stats), |
There was a problem hiding this comment.
Is this arm reachable in practice? It only applies when the cardinality estimate returned Absent for total_byte_size while the left input still has both total_byte_size and num_rows — and your semi-join test takes the column-derived path instead, so it doesn't exercise this branch. If the combination does occur (a source with a file-level size but no per-column byte_size, say), a test with left stats where total_byte_size is present but per-column byte_size is absent would pin the behaviour down.
| // Only fill a gap: a size the join derived from its column statistics | ||
| // knows which columns it keeps, which an average row width cannot. | ||
| (Precision::Absent, Some(width)) => { | ||
| Precision::Inexact((stats.num_rows as f64 * width) as usize) |
There was a problem hiding this comment.
Since total_byte_size feeds hash_join_single_partition_threshold in join_selection.rs, this estimate now decides whether a join becomes CollectLeft — which is exactly the effect your benchmarks show. For fixed-width columns the average row width is the true width, but for Utf8/List it can sit well below the widest rows. Have you considered whether an under-estimate here can flip a large build side into CollectLeft and raise memory use? If that's a real risk, rounding the derived size up — or only filling the gap for fixed-width schemas — might be worth considering.
Which issue does this PR close?
Rationale for this change
Currently it returns total_byte_size: Precision::Absent` for most joins - we can derive it to produce better estimates for nested joins.
What changes are included in this PR?
The width of one output row follows from the sides a join emits, which the join type
already says: both sides for an inner or outer join, the preserved side for a semi or
anti join, and one side plus a boolean for a mark join. Multiplied by the estimated
cardinality, that gives the output size.
Are these changes tested?
Yes. A new unit test covers an inner join summing both sides, a mark join adding one
bit per row, and a semi join keeping its column-derived size.
Benchmarks
TPC-DS SF1: three plans change, each because a join that
now reports a size clears
hash_join_single_partition_thresholdand becomesCollectLeft.No query regressed.
Are there any user-facing changes?
No API change. Joins now report a
total_byte_sizewhere they previously reportednone, which
EXPLAIN ANALYZEand statistics-driven decisions can read.